Unity専用最速DIコンテナVContainer と、UnityにおけるDIの勘所
2021/1/13
https://gyazo.com/b6fa98948f4f79058b78d523fc4c96d9
自己紹介
ソフトウェアエンジニア (フリーランス)
https://gyazo.com/de1fafd691436cb1c1ca3d18cdded180
最近つくっとるライブラリ
最近のひとり個人制作
ゲーム
思い出回収委員会 (仮)
Webサービス
ここは夜です(仮)
最近の仕事
なんか色々やってる
Unity専用 の DI コンテナ
Unity に DI ? → この話の後半で
特徴
低オーバヘッド
Unity専用DI界で圧倒的な実行速度
GCゴミが非常に少ない / コードサイズがとても小さい
必要十分な機能を厳選
目的を見失った滅多打ちのDIを非推奨にしていくスタイル
透明性(=何が起きてるかわかりやすい)があるシンプルAPI
非同期を想定
非同期なアセット/シーンのロードを自由に記述可能
プロジェクト固有の選択を邪魔しない
VContainer DIコンテナとしての性能
https://gyazo.com/8b1ae955a4375bfdbb44163899801f79
Zenject(有名) より 10倍〜速(当社比)。Unity Editor上では30倍くらい速い。
複雑なケースになるにつれてのパフォーマンス劣化が小さい
= 低オーバヘッドだから
IL生成によるメタプログラミング焼き込みモードでさらに5倍速
× DI は遅い
○ 残念ながらZenjectが遅い
リフレクションは、せいぜいメソッド呼び出しの数十倍遅い程度。
普通はそこまで問題にならない
Zenject は シーン内の全MonoBehaviourに対してのリフレクション、Resolveでのアロケーションがあるので、影響が表面化しかねないレベル
VContainer GC Alloc
https://gyazo.com/3e8ed08c11573ca3d8d46078e175850b
VContainerのResolveは ゼロアロケーション を達成
GC は遅いとか速いとかより Stop the Worldが問題
GC.Collect で Unityの全マネージドスレッドは停止
メインスレッドを止めることが体験の劣化につながるソフトウェアではGCがあること自体がハンデ
GCゴミを減らすこと = 実行速度よりも時に大事
→ Apple が iOS SDK に GC を載せない理由
VContainer コードサイズ
https://gyazo.com/476de69d0c786a523ebbdb24a9758024
ランタイム部分の コードサイズ Zenjectのおよそ $ \frac{1}{10}
クライアントサイドにでかいもの入れたくない教に朗報
IL → C/C++ 変換時のサイズ増加割合が少なめ
内部の型の複雑な継承関係がない
.callvirt より .call
.call 命令 → C++ の関数呼び出しにマッピングされる
.callvirt命令 → IL2CPPが吐いた型ごとのvtable経由での関数呼び出し
VContainerの「V」とは…
Unityの頭文字「U」を、夜な夜な彫刻刀で1ミリずつ削ってスリムにした形状をイメージ(観念の世界)
ゲームにDIを持ち込むことのメリットってあんの?
1. Inversion of Control (IoC)
一貫した単純な 制御フローの強制に一役
DI ← マーティン・ファウラーが名付ける以前は IoC Container とも
2. オブジェクト同士の干渉に良い感じの制約をつける
大きめコードベースでは、部品の意図を伝えるために制約は割と有用
3. インターフェイスとかを使った抽象化のサポート
そこまで重要ではない(かも)
教科書的な話への感想
実装ではなく抽象に依存させる ?
これは目的ではなく手段
抽象化はコストがかかる。リーズナブルな買い物をしよう
とはいえ Unityは Interfaceのサポートが弱いので有用な場面はある(かも)
テスタビリティ ?
DI しなくてもスタブは挿せる(とマーティンファウラーも言ってました
ユニットテストがマッチするような場所はそもそも変な依存を取り除く
ゲーム特有の難しいとこ
素朴なUnityプログラミングでは、命令を下す側と、命令を下される側が混ぜこぜになりやすい
Unityのオブジェクトは、実行時にめまぐるしく生成/破棄が繰り返される
DI が育まれた javaウェブアプリケーション世界ではそんなことは起こらない
→ ゲームの複雑さそのものはDIでは解決できない。
→ シンプルなDI でOKな構造 = シンプルな構造
Unity = Easy to use
GameObject
シリアライズとツールの統合
機能を Componentで分割しつつ、内側にカプセル化
Prefab の最強ポータビリティ
→ 使いやすい。ありがとうUnity。ベストフレンドフォーエバー。
自身が内側に隠蔽しているdオブジェクトは詳細を知っていて特に問題ない。
Prefabの最強ポータビリティをDIで殺すのはもったいない
GameObject = Component Entitity pattern
https://gyazo.com/a6d6c9faf76a26f84f397e97a0641943
シーン上のオブジェクトは それ単体で 完全に動作する
直感的でわかりやすい
水平分割のみのサポート
Unityに足りないもの
GameObject 同士の メッセージのやりとりのサポートが微妙
素朴に考えると、GameObject同士の関連が爆発してしまう
ゲームにおけるオブジェクトの参照関係を考えよう
https://gyazo.com/94638b7053c465a35422158bee6dfdc9
人間の脳内での素朴な参照関係
https://gyazo.com/ec081eb723beac17d672a4c197f8dc58
N:Nの関係構築の連打
→ 茹で時間: 1分
オブジェクト指向のオブジェクトとは…
オブジェクトは、(直接的には)メソッドを叩く/叩かれる という通信方法しか持たないので
「犬」とか「猫」みたいな現実世界で認識している「物」をまんまモデル化しようとしただけでは、参照関係がえらいことに
Unity = Easy to use
問題1 ゲームのオブジェクトの不安定な寿命
https://gyazo.com/30c647361b55dec22a928dfaf813138f
生成/破棄がいつ起こるかわからない。実行時の条件分岐で 生成/破棄が繰り返される
N:N 関連が爆発したままこれをすると…
→ 実行時エラー
→ リソースのリーク
→ // なぜか1フレーム待つと直る
問題2 複雑な制御フロー
https://gyazo.com/c146d71e28752bff2b7590eb30878dfe
スパゲティコードとはつまり
誰もが命令を下す側
誰もが命令を下される側
になっている状態のこと
アプリケーション設計は、クリーンかどうかとかより、メッセージングの方向が重要
上記のような関係性のまま、 Interfaceを切ろうが、型を挟もうが、複雑さを解決できてない
問題1 の対策. 依存関係を根本的に単純にする
https://gyazo.com/91d67e6cd01875d053e276d9820eaf0f
1:1 関係に直す
DDD / Data-Centric
「データ」は不安定な寿命をもつ
データに強く結びついたオブジェクトも当然 不安定な寿命を持つ
GameObjectも当然不安定な寿命を持つ
「純粋関数」は永遠の寿命を持つ
「データ」依存部分と「機能」を分離
→ Unity も ECS はこっちの設計に寄ってきたで
参照解決の難易度激減
問題2の対策. 制御フローを根本的に単純にする
https://gyazo.com/e717718360251069628bfe88c756e380
制御の反転 = IoC
→ Actorをエントリポイントにしない。
命令を下す人 = とても特別な存在
Mediator / Event Aggregator / 中央メッセージング 的なパターンが割と有用
命令を下す人
生涯を通して誰からも直接的には指図されることはない
命令を下される人 = その他すべて
デバッグ/テスト しやすい
シンプルな構造を保つために
複雑さを抱えたまま 強力機能やテクニックを持ち込んでも、複雑さの上に複雑さを上塗りするだけ
→ DI や強力機能は、シンプルにするための制約づけや補助のためにこそむしろ使える(という提案)
Unityが搭載している実行時の参照解決
FindObjectObType / FindGameObject*
誰でも どこでも 実行できてしまう
一週間に一回しか通らない分岐でのみ参照をとろうとして壊れていたら?
バグ報告/調査/修正/レビュー/デバッグ/リリース、全ての工程がもったいない
設定内容はビルド時に保証したい
OOPでの参照解決テクニック
1. どこからでもアクセスできる大域変数/メソッド
Singleton / static / またはそれの importとか
わかりやすい。楽。
Web界隈の後発の環境は割とこれな気がする
2. Service Locator
static変数経由の変形
どこからでもアクセスできる、オブジェクト参照のレジストリをつくる
3. Dependency Injection / IoC Container
オブジェクトのコンストラクタへ、外側から参照を渡してあげる
型システム強力言語の参照解決テクニック
4. Scala発祥 Cake Patetrn など
ひとつのtraitの中に、クラスの実装と、空っぽの依存オブジェクト変数宣言 をまとめるテクニック
traitの中のクラスは、「依存オブジェクト変数」がないと 使用が不可能なことが保証される
→ コンパイル時に解決
関数型言語での参照解決テクニック
5. Readerモナド
モナド = 純粋関数 に おまけ(副作用) をつける関数型の一般的なテクニック
依存解決=副作用 。 「依存注入」機能をモナドでラップ
→コンパイル時に解決
クロージャ内に依存をキャプチャ
依存解決後の関数を返す関数を扱う
DIのまあまあ良いところ
コーディングスタイルにそこまで干渉しない
コンパイル時ではないにせよ、スコープ開始時の早期にバリデーションできる
何に依存しているか宣言的で明示的になる
誰がいつ参照を取りにくるかルール違反しずらい
オブジェクトの寿命の管理を DI コンテナに肩代りさせることができる
→ スコープという概念がある
DIにおけるスコープという概念
https://gyazo.com/a8dc786f4adb61b1bd1f18151d88c31c
DIコンテナで管理されるオブジェクトは、コンテナがライフサイクルの面倒を見る
階層構造になっている
HTTPサーバでは…
リクエスト単位でスコープができることが自明
通常「スコープの生成/破棄」はフレームワークが勝手にやってくれる
アプリケーションの制御フローよりも下
DIのセットアップ3ステップ
1. いつスコープができていつ破棄されるべきかの決定
フレームワークの実装に含まれている
アプリケーションの制御フローの関心の外
2. アプリケーション側の設定
参照させたいオブジェクトの登録 (Composition Root)
3. アプリケーション側の実装
設定と実装の分離
ゲームにおけるスコーピング
HTTPサーバと違って、いつどこでどのようにスコープを生成するべきか自明ではない
シーン単位
シーンより細かい単位 / etc..
プロジェクト毎に決めたい
非同期にリソースをロードした後にスコープを構築したい
非同期のためのソリューションもプロジェクト毎に異なっている
UniTask / Task / IValueTaskSource / UniRx / Unity コルーチン/ コールバック / etc..
VContainerの特徴 - スコープ制御
いつでもどこでも非同期ロードした後でもスコープを宣言できる
依存関係の設定と、スコープの生成 は分かれている
→ DI のライブラリの一般的な姿
→ Zenject は 1 と 2 がくっついているがけっこう特殊
VContainerの特徴 - エントリポイント
RegisterEntryPoint<T>(...) で、独自PlayerLoopSystem上にスケジュールされる
MonoBehaviourに依存せずにエントリポイントになれる
→ MonoBehaviourの外側から「命令を下す人」をつくれる
VContainerの特徴 - エントリポイント
MonoBehaviour に DI をすることを積極的には推奨していない
どちらかというと MonoBehaviour を DI することを推奨
MonoBehaviour は「命令を下される側」の人と見做した方が良い
そのためのDI
そのためのIoC
GameObjectにたくさんのものを外から注入する必要があんまない
Viewコンポネに状態を渡したいことはしょっちゅうある?
それは「依存性の注入」じゃなくてただのメソッドの引数
View内部にさらに複雑なパターンが隠蔽されているときは?
外から隠蔽しているものは密結合していてあんまり問題ない
PrefabのポータビリティをDIで壊すのはもったいない
まとめ
ゲームにシンプルなDI を持ち込んで嬉しいポイント
制御フローをシンプルにすることの補助に使える
オブジェクト間の複雑すぎる関連を抑止する制約をつけることができる
制御フローや依存関係がそもそも複雑なままDIをしても余計複雑になるだけ
VContainer は必要十分だし速い
よかったら Github の ☆ も押してね m(_ _)m